feat(backchannel): add agent backchannel - #6130
Conversation
| tts_instructions_template: Instructions | str | ||
| tts_instructions_append: str | ||
| audio_recognition_instructions_template: Instructions | str | ||
| backchannel: NotGivenOr[bool | list[str | AudioSource | BackchannelConfig] | BackchannelOptions] |
There was a problem hiding this comment.
i'm wondering if we could simplify these types somehow
There was a problem hiding this comment.
claude suggested:
# backchannel.py
+ BackchannelSource: TypeAlias = "str | AudioSource | BackchannelConfig"
class BackchannelOptions(TypedDict, total=False):
frequency: float
- source: NotGivenOr[list[str | AudioSource | BackchannelConfig]]
+ source: list[BackchannelSource]
- DEFAULT_BACKCHANNEL_SOURCE: list[str | AudioSource | BackchannelConfig] = [...]
+ DEFAULT_BACKCHANNEL_SOURCE: list[BackchannelSource] = [...]
def resolve_backchannel_options(
- backchannel: NotGivenOr[bool | list[str | AudioSource | BackchannelConfig] | BackchannelOptions],
+ backchannel: NotGivenOr[bool | list[BackchannelSource] | BackchannelOptions],
) -> BackchannelOptions | None: ...
- def _as_config(entry: str | AudioSource | BackchannelConfig) -> BackchannelConfig: ...
+ def _as_config(entry: BackchannelSource) -> BackchannelConfig: ... # agent_session.py
class ExpressiveOptions(TypedDict, total=False):
...
- backchannel: NotGivenOr[bool | list[str | AudioSource | BackchannelConfig] | BackchannelOptions]
+ backchannel: bool | list[BackchannelSource] | BackchannelOptions…channel-expressive-mode
…channel-expressive-mode # Conflicts: # examples/hotel_receptionist/agent.py
tsushanth
left a comment
There was a problem hiding this comment.
Thoughtful design. The EOT-band gating (safe sounds near threshold, risky lexical words only at low EOT fractions), the per-clip cooldown via _HOTNESS_DECAY, and the explicit TTFF cap on the first render all show genuine engineering taste about the latency/relevance tradeoff that's specific to backchannels — "a late one is worse than none" is exactly the right framing. The 300ms first-frame budget with silent-drop fallback is good defensive design.
One concrete architectural concern, plus a few worth-flagging items.
Must-confirm
1. activity.say(...) while the agent may already be mid-utterance.
on_agent_backchannel_opportunity fires (per audio_recognition.py:1511) when the cloud turn detector emits a backchannel signal — which is gated by the user's speech state. But the agent's speech state isn't checked anywhere in _BackchannelEmitter. The edge case I'm worried about:
- Agent finishes saying a long response, the activity is still draining the last few audio frames
- User starts a new utterance overlapping the tail of the agent's audio
- Turn detector emits a backchannel opportunity ("user has just started talking")
maybe_emit→_play→activity.say(transcript, audio=_iter_frames(frames), allow_interruptions=False)- The backchannel audio now overlaps the agent's own still-draining response
activity.say may already gate this — I don't see the implementation in the diff, but if it queues backchannel speech behind active speech, the backchannel arrives late (and the in-code comment "a late one is worse than none" applies — better to drop than queue). If activity.say plays immediately on a separate channel, you get overlap of the agent's own audio.
Two clean resolutions, either works:
- (a) Gate
maybe_emitonnot activity.is_speaking()(or whatever the AgentActivity equivalent is) before adding to the pool. Backchannels are dropped silently when the agent is mid-utterance. - (b) Document the contract on
activity.sayand either rely on it (with a comment in_playreferencing the assumed behavior) or pick (a).
Worth confirming the current behavior with a test that has the agent mid-say() when a backchannel opportunity fires — currently test_backchannel.py doesn't appear to exercise this overlap case based on a quick scan.
Worth discussing
A. _synthesize enforces TTFF only on the first frame:
first = await asyncio.wait_for(it.__anext__(), timeout=_SYNTH_TTFF_TIMEOUT)
frames.append(first.frame)
async for ev in it: # no timeout on subsequent frames
frames.append(ev.frame)The first-frame cap matches the "late one is worse than none" design — good. But once the first frame arrives, the remaining frames have no time budget. A TTS that's fast on TTFF but slow on subsequent frames could hang the _render task. For typical backchannel clips (sub-second utterances) this is unlikely to be a real issue, but a total-time budget (e.g. _SYNTH_TTFF_TIMEOUT * 4 for the full clip) would be a cheap defensive guard.
B. _clip_key(source) for AsyncIterator sources uses id(source) and the iterator is one-shot:
def _clip_key(source: str | AudioSource) -> str:
...
return f"iter:{id(source)}"If _render raises before populating self._cache[key] (the broad except Exception swallows it), the iterator is consumed but no cached frames exist. The next opportunity for the same clip key re-enters _render_and_play, but _decode_source(source) returns the same already-consumed iterator. Result: empty frames list, silent failure on every subsequent attempt.
For str (TTS text) or BuiltinAudioClip sources this isn't an issue — re-rendering / re-decoding from a file works. Only the raw AsyncIterator case has the one-shot consumption hazard. Two options:
- Materialize the iterator into a list eagerly inside
_decode_source(defeats some of the streaming benefit but avoids the silent-failure case) - Detect the failed-render case explicitly and refuse to register the same iterator key (force the caller to provide a factory function rather than a raw iterator)
C. Cloud-turn-detector-only gate with only a warning log:
if not isinstance(self._turn_detection, inference.TurnDetector):
logger.warning(
"backchannel is enabled but the active turn detector does not provide a "
"backchannel signal (requires the LiveKit cloud turn detector); disabling it"
)
return NoneA user who passes backchannel=True and never sees a backchannel fire (because they're on the local mini detector) will likely scroll past a WARNING log line. The contract that backchannels require the cloud detector is significant enough to surface in:
- The
BackchannelOptionsTypedDict docstring - A note in the
ExpressiveOptions["backchannel"]documentation (when those docs land — see item E) - Possibly upgrade the log to
ERRORfor visibility, since the feature is silently disabled
Quality-of-merge
D. Empty PR body. The architectural decisions here (EOT-band gating, render-then-cache, TTFF cap, frequency pre-gate) all warrant 2-3 sentences in the PR description so a maintainer reading the diff doesn't have to reverse-engineer the design from the inline comments. Most of the design rationale is already written excellently as docstrings in backchannel.py — just hoisting the key paragraphs into the PR body would be enough.
E. No public docs entry. BackchannelOptions (TypedDict) and BackchannelConfig (dataclass) are exposed via livekit/agents/__init__.py and voice/__init__.py — both are public surface that users will configure. Worth a docs page or at least an expressive config section update. (Mentioning because the in-tree docstrings are good; just need to be discoverable.)
Acknowledging what's good
A few things that are worth not regressing during iteration:
- The two-tier default source list (safe sounds near EOT threshold, risky words only at low EOT) shows real understanding of the backchannel UX failure modes
_HOTNESS_DECAY = 3with_cooldownreturning1.0 - heatis a clean implementation of recency-suppression without needing time tracking- The render-once-then-cache design is the right shape — pre-warming at activity init would be marginally better but adds complexity that's hard to justify before observing the failure mode in production
add_to_chat_ctx=Falsecorrectly excludes backchannels from conversation history
Decision
Requesting changes — but only because of item 1 (the agent-speech overlap question). The other items are worth-flagging or polish. If activity.say already gates against active speech, item 1 reduces to a documentation ask; if it doesn't, the gate needs adding.
Happy to re-approve once item 1 is confirmed/resolved.
18731da to
b6d5e57
Compare
| if delta: | ||
| data.generated_text += delta | ||
| text_ch.send_nowait(parsed) | ||
| prev_response_text = new_response |
There was a problem hiding this comment.
🔴 Agent replies vanish from the conversation transcript and history when structured output is enabled
The agent's spoken reply is published as a parsed data object instead of its text (text_ch.send_nowait(parsed) at livekit-agents/livekit/agents/voice/generation.py:226), and the transcript path discards anything that isn't text, so the user sees no captions and the reply is never saved to the conversation history.
Impact: With an agent that uses structured output, the spoken answer is missing from live captions and from the chat history, so the model cannot see what it already said and repeats itself.
Mechanism: BaseModel chunks are dropped by the transcript reader, leaving forwarded_text empty
When Agent.llm_output_format is set, _llm_inference_task never sends plain strings on text_ch — every content delta is folded into json_buffer and only the parsed BaseModel is forwarded (livekit-agents/livekit/agents/voice/generation.py:218-227, plus the final strict parse at :236-243).
Downstream in livekit-agents/livekit/agents/voice/agent_activity.py:
_produce_segmentsonly appendsstrchunks toassistant_llm_text_parts(agent_activity.py:2939-2940), so the raw-text capture is empty._read_segment_textexplicitlycontinues onBaseModelchunks (agent_activity.py:3013-3016), so the transcription node receives an empty stream.- Consequently
forwarded_text = "".join(out.forwarded_text ...)is""(agent_activity.py:3199) and theif forwarded_text:guard atagent_activity.py:3211skips creating and inserting the assistantChatMessageentirely (which is also wheremsg.llm_outputwould be attached,agent_activity.py:3244-3245).
Only the use_tts_aligned_transcript path (off by default) supplies text from the TTS instead, masking the problem.
The TTS path itself works because Agent.default.tts_node extracts the response field delta from the BaseModel (livekit-agents/livekit/agents/voice/agent.py:580-589); the transcript path has no equivalent.
Prompt for agents
When an Agent declares `llm_output_format`, the LLM generation task only pushes parsed pydantic models onto `text_ch` (see `_llm_inference_task` in livekit-agents/livekit/agents/voice/generation.py). The TTS path handles this (Agent.default.tts_node extracts the response-field delta), but the transcript path does not: `_read_segment_text` in livekit-agents/livekit/agents/voice/agent_activity.py skips every non-str chunk, and `_produce_segments` only accumulates str chunks into `assistant_llm_text_parts`. The result is an empty `forwarded_text`, which means the assistant ChatMessage is never inserted into the chat context and no transcription is streamed to the client (unless use_tts_aligned_transcript happens to be enabled).
Fix by making the transcript path aware of structured-output chunks — e.g. have `_read_segment_text` extract the incremental response-field text from BaseModel chunks the same way `tts_node` does (tracking previously emitted text so only the delta is yielded), and make `assistant_llm_text_parts` capture that same text so expressive markup capture keeps working. Ensure the delta bookkeeping is per-segment and consistent with what the TTS actually speaks.
Was this helpful? React with 👍 or 👎 to provide feedback.
| class Markup(tts.TTS.Markup): | ||
| # markup delegation lives in the base class, keyed on _provider_key() | ||
| def _provider_key(self) -> str: | ||
| # only inworld-tts-2 understands the markup tags; older models get no | ||
| # markup so the tags aren't injected, converted, or stripped (matches | ||
| # the inference gateway's behavior) | ||
| return "inworld" if "tts-2" in self._tts.model else "" |
There was a problem hiding this comment.
🔴 Expressive speech tags are sent verbatim to the voice provider when using the direct Inworld/ElevenLabs plugins
The expressive tag vocabulary is advertised to the model for the Inworld plugin (_provider_key() at livekit-plugins/livekit-plugins-inworld/livekit/plugins/inworld/tts.py:978-982) but the plugin never translates those tags into the provider's own syntax before speaking, so the raw tag text can be read out loud or ignored.
Impact: Agents configured with expressive speech and a direct provider plugin (rather than LiveKit Inference) can speak gibberish like "expression value speak cheerfully" to the caller.
Mechanism: normalize()/convert() are only wired into the inference gateway path
TTS.Markup.normalize() and TTS.Markup.convert() are defined in livekit-agents/livekit/agents/tts/tts.py:134-149. convert_markup (livekit-agents/livekit/agents/tts/_provider_format.py:567-574) rewrites <expression value="X"/> → [X] for inworld and elevenlabs_v3, and <break .../> → ... for inworld — i.e. the framework-standard XML is not the providers' native syntax.
The only call sites for normalize/convert are in the inference gateway: livekit-agents/livekit/agents/inference/tts.py:656 and :667. A grep of livekit-plugins/ finds no markup. usage at all; the Inworld plugin pushes text straight into its tokenizer (livekit-plugins/livekit-plugins-inworld/livekit/plugins/inworld/tts.py:1278), and the ElevenLabs plugin does the same.
Because the plugins' new Markup._provider_key() overrides return a non-empty key, AgentActivity._inject_expressive_instructions will happily inject the tag instructions into the LLM prompt (livekit-agents/livekit/agents/voice/agent_activity.py:2445), so the model does produce the XML tags — they just never get converted on the way out.
Same issue applies to livekit-plugins/livekit-plugins-elevenlabs/livekit/plugins/elevenlabs/tts.py:214-220 for the elevenlabs_v3 key.
Prompt for agents
The new TTS.Markup abstraction (livekit-agents/livekit/agents/tts/tts.py) exposes normalize() and convert(), and _provider_format.convert_markup() rewrites framework-standard <expression>/<sound>/<break> XML into each provider's native format (bracket tags for inworld and elevenlabs_v3, ellipsis for inworld breaks). However only the inference gateway (livekit-agents/livekit/agents/inference/tts.py) calls markup.normalize()/markup.convert() before sending text to the provider. The direct provider plugins (livekit-plugins-inworld, livekit-plugins-elevenlabs) added Markup._provider_key() overrides — which is enough for the framework to inject tag instructions into the LLM prompt and to strip tags from the transcript — but they never apply the conversion on their send path, so the provider receives raw XML.
Either apply normalize()/convert() centrally (e.g. in the framework right before pushing text into the TTS stream, so every plugin benefits) or add the calls in each plugin's SynthesizeStream input handling, mirroring what inference/tts.py does at the chunk and sentence level.
Was this helpful? React with 👍 or 👎 to provide feedback.
| def stt_context(self) -> BaseModel | None: | ||
| """Live speaker metadata from the STT stream. | ||
|
|
||
| STT plugins set ``RecognizeStream.context`` during recognition. | ||
| The framework copies it here so it's accessible even after the stream | ||
| is replaced (e.g. during agent handoff). | ||
| """ | ||
| return self.__stt_context | ||
|
|
||
| @stt_context.setter | ||
| def stt_context(self, value: BaseModel | None) -> None: | ||
| self.__stt_context = value |
There was a problem hiding this comment.
🟡 Speaker-context instructions promised by the expressive feature are never actually produced
The live speaker information from speech recognition is never copied into the place the expressive system reads it from (stt_context at livekit-agents/livekit/agents/voice/audio_recognition.py:351-362), so the speaker-adaptation instructions are always skipped.
Impact: The "adapt your tone to the detected speaker" half of every expressive preset silently never reaches the model.
Mechanism: RecognizeStream.context is never propagated to AudioRecognition
RecognizeStream gains a context property that STT plugins are expected to set (livekit-agents/livekit/agents/stt/stt.py:351-365), and AudioRecognition.stt_context documents that "the framework copies it here so it's accessible even after the stream is replaced".
A repo-wide grep for stt_context finds only the definition, the getter/setter, and the read at livekit-agents/livekit/agents/voice/agent_activity.py:2461. Nothing ever assigns it, and nothing reads RecognizeStream.context either. AudioRecognition.llm_instructions() therefore always returns None, and the stt_ctx is not None and llm_instr is not None guard in _inject_expressive_instructions (agent_activity.py:2461-2463) never passes — so audio_recognition_instructions_template (defined for every preset in livekit-agents/livekit/agents/tts/_provider_format.py) is dead configuration.
Prompt for agents
AudioRecognition.stt_context is documented as being populated by the framework from RecognizeStream.context (see the docstrings in livekit-agents/livekit/agents/voice/audio_recognition.py and livekit-agents/livekit/agents/stt/stt.py), but no code path ever assigns it. As a result AudioRecognition.llm_instructions() always returns None and the audio_recognition_instructions_template branch in AgentActivity._inject_expressive_instructions never fires, making the speaker-context half of every expressive preset dead code.
Wire the propagation: when the STT pipeline is created/updated (_update_stt / _STTPipeline in audio_recognition.py) or when STT events are consumed, read the active RecognizeStream's `context` and mirror it onto AudioRecognition.stt_context so it survives stream replacement during agent handoff. Add a unit test covering an STT stub that sets context and implements SpeakerContext.
Was this helpful? React with 👍 or 👎 to provide feedback.
| def _provider_key(self) -> str: | ||
| model = self._gateway_tts._opts.model | ||
| provider = model.split("/")[0] | ||
| if provider == "inworld" and "tts-2" in model: | ||
| return "inworld" | ||
| elif provider == "inworld": | ||
| return "" # older inworld models don't support markup | ||
| return provider |
There was a problem hiding this comment.
🟡 ElevenLabs v3 voices get the wrong expressive tag instructions when routed through LiveKit Inference
The gateway picks the tag vocabulary purely from the provider prefix (_provider_key() at livekit-agents/livekit/agents/inference/tts.py:463-470) and never special-cases the v3 voice model, so the model is told to emit pause and pronunciation tags instead of the delivery tags that voice actually understands.
Impact: Selecting the ElevenLabs v3 voice in the playground with expressive enabled yields flat delivery and possibly stray tag text, unlike the same model used through the direct plugin.
Mechanism: gateway key vs. plugin key divergence
The ElevenLabs plugin distinguishes v3 explicitly (livekit-plugins/livekit-plugins-elevenlabs/livekit/plugins/elevenlabs/tts.py:214-220), returning "elevenlabs_v3" for models containing v3, which maps to _ELEVENLABS_V3_LLM_INSTRUCTIONS / _ELEVENLABS_V3_TAGS and to convert_expression_tags in livekit-agents/livekit/agents/tts/_provider_format.py:518-574.
The gateway Markup._provider_key() only special-cases inworld; for elevenlabs/eleven_v3 it returns "elevenlabs", yielding the non-v3 <break> / <phoneme> instruction set, non-v3 stripping tags, and no bracket conversion. elevenlabs/eleven_v3 is an exposed option in examples/playground.yaml:265.
| def _provider_key(self) -> str: | |
| model = self._gateway_tts._opts.model | |
| provider = model.split("/")[0] | |
| if provider == "inworld" and "tts-2" in model: | |
| return "inworld" | |
| elif provider == "inworld": | |
| return "" # older inworld models don't support markup | |
| return provider | |
| def _provider_key(self) -> str: | |
| model = self._gateway_tts._opts.model | |
| provider = model.split("/")[0] | |
| if provider == "inworld" and "tts-2" in model: | |
| return "inworld" | |
| elif provider == "inworld": | |
| return "" # older inworld models don't support markup | |
| if provider == "elevenlabs" and "v3" in model: | |
| return "elevenlabs_v3" | |
| return provider |
Was this helpful? React with 👍 or 👎 to provide feedback.
| token_pkt["generation_config"] = generation_config | ||
| token_pkt["extra"] = self._opts.extra_kwargs if self._opts.extra_kwargs else {} | ||
| self._mark_started() | ||
| await ws.send_str(json.dumps(token_pkt)) | ||
| payload = json.dumps(token_pkt) | ||
| logger.debug("[TTS→gateway] %s", payload) | ||
| await ws.send_str(payload) |
There was a problem hiding this comment.
🟨 Full TTS request payload (including synthesized text) logged at debug level
The inference TTS stream now logs the complete JSON payload sent to the gateway, including the user-facing conversation text being synthesized (logger.debug("[TTS→gateway] %s", payload) at livekit-agents/livekit/agents/inference/tts.py:681, plus the converted text at :668). In production deployments where debug logging is enabled, this writes potentially sensitive conversation content (names, card digits collected by the credit-card workflow, medical details from the healthcare example) into logs.
(Refers to lines 665-682)
Was this helpful? React with 👍 or 👎 to provide feedback.
No description provided.